iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 25 篇

Day 25|如何優雅處理 Token 失效?

  • 分享至 

  • xImage
  •  

午休回來,五個請求同時 401

測試回報很具體:「開著儀表板去吃午餐,回來點一下重新整理,畫面閃了好幾次,最後跳到登入頁,登入完又跳一次。」

我照著做了一遍。打開 Console,裡面是一整排紅字:

BrowserAuthError: interaction_in_progress
BrowserAuthError: interaction_in_progress
BrowserAuthError: interaction_in_progress
BrowserAuthError: interaction_in_progress

往回追,事情是這樣發生的。

儀表板一打開,會同時發出五個請求:檔案清單、空間用量、最近開啟、通知數量、使用者設定。剛好在這個時間點,後端判定這張 token 已經不能用了——可能是時間差、可能是權限剛被調整過,前端的 MSAL 還以為它有效。

五個請求同時回來 401。

攔截器寫的是「遇到 401 就 loginRedirect」。於是五個請求,各自呼叫了一次 loginRedirect。第一個成功開始了登入流程,剩下四個撞上「已經有一個登入在進行中了」,噴出那一排錯誤。

更糟的是,TanStack Query 看到請求失敗,預設會重試三次。每一次重試又是 401、又是一次 loginRedirect。

一個 token 失效,觸發了將近二十次登入。


一、這也是一種競態

講過競態:兩個請求誰先回來,是網路說了算,晚到的會蓋掉早到的。

今天這個是同一家族的問題,只是換了一個戰場:五個請求同時失敗,每一個都獨立決定「我來處理」。 沒有任何一個人知道其他人也正在做同一件事。

真正需要的行為很簡單:

第一個 401 回來 → 去換一張新 token
第二個 401 回來 → 發現已經有人在換了,等它換完
第三、四、五個   → 同上
新 token 拿到了  → 五個請求一起用新 token 重送

只換一次,大家共用結果。


二、Angular 是怎麼寫的

在 Angular 寫過 token 刷新的人,應該對這個模式不陌生:

let refreshing$: Observable<string> | null = null;

export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);

  return next(req).pipe(
    catchError(error => {
      if (error.status !== 401) return throwError(() => error);

      refreshing$ ??= auth.refreshToken().pipe(
        finalize(() => (refreshing$ = null)),
        shareReplay(1),
      );

      return refreshing$.pipe(
        switchMap(token => next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))),
      );
    }),
  );
};

關鍵是那個 shareReplay(1)。Day 13 介紹過它:多個訂閱者共用同一個執行中的 Observable,只會真的執行一次。第一個 401 建立了 refreshing$,後面四個訂閱的都是同一個。

我花了不少時間才把這段寫對——什麼時候要 share、什麼時候要清掉、switchMap 會不會又多觸發一次。

到了 React,我以為又要重新刻一套。結果答案簡單到讓我愣住。


三、Promise 版:大家看同一張收據

把 Promise 比喻成一張收據:一建立就出發,未來會有一個結果。

收據有一個特性我當時沒提:同一張收據,可以給很多人看。 一個 Promise 可以被 await 很多次,每個人拿到的都是同一個結果,而裡面的工作只會執行一次。

這正好就是「只換一次,大家共用」:

// lib/apiClient.ts
let refreshPromise: Promise<string> | null = null;

function refreshTokenOnce(): Promise<string> {
  if (!refreshPromise) {
    refreshPromise = msalInstance
      .acquireTokenSilent({
        scopes: [import.meta.env.VITE_API_SCOPE],
        account: msalInstance.getActiveAccount()!,
        forceRefresh: true,   // 不要用快取,一定要換一張新的
      })
      .then(result => result.accessToken)
      .finally(() => {
        refreshPromise = null;   // 換完了,下次需要時再重新開始
      });
  }
  return refreshPromise;
}

第一個呼叫的人,建立了這張收據。之後的人進來,發現收據已經存在,直接拿同一張回去等。換完之後清掉,下一次 token 又失效時,才會重新建立一張。

跑一次看看:

請求 A 回 401 → refreshPromise 是 null → 建立,開始換 token
請求 B 回 401 → refreshPromise 已存在 → 拿同一個,等
請求 C、D、E  → 同上
新 token 回來 → A、B、C、D、E 同時拿到同一張新 token
finally       → refreshPromise 清回 null

不需要 shareReplay、不需要 switchMap、不需要任何運算子。一個變數,加上 Promise 本來就會的事。

之前說 Observable 是藍圖、Promise 是收據,那時是在講 Promise 的限制。今天第一次覺得,收據也有收據的好處。


四、拿到新 token 之後,重送原本的請求

接到回應攔截器上:

apiClient.interceptors.response.use(undefined, async error => {
  const config = error.config;

  if (error.response?.status !== 401 || config._retried) {
    return Promise.reject(error);
  }

  config._retried = true;   // 標記:這個請求已經重送過一次了

  try {
    const token = await refreshTokenOnce();
    config.headers.Authorization = `Bearer ${token}`;
    return apiClient(config);   // 用新 token 重送
  } catch {
    redirectToLoginOnce();
    return Promise.reject(error);
  }
});

兩個細節都是用來防止無限循環的:

_retried 標記。 每個請求只重送一次。如果用新 token 重送之後還是 401,代表問題不在 token 過期,而是這個人真的沒有權限,或是後端出了別的狀況。這時候就不該再換了,讓錯誤往上丟。

換不到 token 時,只導頁一次。 如果 acquireTokenSilent 本身也失敗了(例如登入狀態整個失效),才需要真的重新登入:

let redirecting = false;

function redirectToLoginOnce() {
  if (redirecting) return;
  redirecting = true;
  msalInstance.loginRedirect({ scopes: [import.meta.env.VITE_API_SCOPE] });
}

五個請求同時走到這裡,也只會有一個真的觸發登入。那排 interaction_in_progress 消失了。


五、等等,這不就是模組層級的狀態嗎?

寫完我自己愣了一下。refreshPromise、redirecting,這兩個都是寫在模組層級的可變變數。

這兩個變數跟使用者有關嗎?有。refreshPromise 裝的就是某個使用者的 token。

它們之所以安全,是因為我們在選了 SPA 模式:所有程式碼只在瀏覽器裡執行,一個分頁只有一個使用者。如果哪天改成 SSR,這兩個變數就會變成整台伺服器共用,A 的請求可能等到 B 換來的 token。

跟使用者有關的狀態,只有在「確定只在瀏覽器執行」的程式碼裡,才能放在模組層級。

同時在 refreshPromise 上面加了一行註解,寫明它依賴 SPA 模式。規則不是死的,但例外要寫清楚理由。


六、別讓兩層都在重試

修完攔截器,還剩一個問題:TanStack Query 的自動重試。

攔截器已經處理了 401 的重送,如果 TanStack Query 又照樣重試三次,401 的情況下等於是重複做一樣的事。更普遍地說,4xx 的錯誤——找不到、沒權限、參數錯誤——重試再多次結果都一樣。

所以把重試規則改成:只有伺服器錯誤或網路問題才重試。

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
          return false;   // 4xx 不重試
        }
        return failureCount < 3;
      },
    },
  },
});

一個原則:每一種失敗,只讓一層負責重試。 401 由攔截器負責,5xx 跟斷線由 TanStack Query 負責,4xx 誰都不重試。


七、MSAL 已經幫了什麼

最後要誠實補一句,避免讀者以為這一切都得自己做。

正常情況下的 token 過期,MSAL 其實已經處理好了。acquireTokenSilent 在 token 快過期時會自動在背景換新的,新版的 MSAL 也會把同一時間、相同參數的多個 silent 請求合併成一次。所以 Day 24 的請求攔截器每次都呼叫 acquireTokenSilent,並不會因此多打很多次 Azure。

今天處理的,是 MSAL 看不到的那種情況:前端以為 token 還有效,後端卻拒絕了它。 這時候只有回應攔截器知道發生了 401,也只有它能決定要不要強制換一張。

如果你的系統不是用 MSAL,而是後端自己發的 JWT 加 refresh token,第三節那個「只換一次、大家共用」的寫法完全一樣,只是把 acquireTokenSilent 換成呼叫你自己的 refresh API。


八、對照一下

Angular React + axios
共用一次刷新 shareReplay(1) 把 Promise 存起來
刷新後重送 switchMap 接 next(req.clone()) return apiClient(config)
避免無限重送 自訂 header 或 context config._retried 標記
避免重複導頁 旗標或 service 裡的狀態 模組層級旗標(僅限 SPA)
上層重試 看各自的 retry 運算子 TanStack Query 的 retry 函式

今天的結論

一個 token 失效,觸發了將近二十次登入。原因不是某一行程式碼寫錯,而是五個同時發生的失敗,各自獨立地決定「我來處理」。

競態換了一個戰場。解法的核心也一樣:讓大家共用同一件事的結果,而不是各自重做一次。

在 Angular,這件事要靠 shareReplay 讓多個訂閱者共用一個執行中的 Observable,寫對它需要對 RxJS 有相當的理解。在 React,Promise 本身就能被很多人等待,只要把它存在一個變數裡就好。

之前替 Promise 列了一堆限制:不能取消、只有一個值、沒有運算子。今天它用最樸素的特性解決了一個問題:一張收據,可以給很多人看。


上一篇
Day 24|每支 API 都回 401:自己蓋一層攔截器
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言